winbrew_app\operations\update/
download.rs1use anyhow::{Context, Result, bail};
2use std::fs::File;
3use std::io::{BufWriter, Write};
4use std::path::{Path, PathBuf};
5use zstd::stream::read::Decoder;
6
7use crate::core::network::{Client, download_url_to_temp_file};
8
9use super::metadata::{load_catalog_metadata, verify_catalog_hash};
10use super::types::CatalogDownloadPlan;
11
12pub(super) fn download_catalog_release<FStart, FProgress>(
30 client: &Client,
31 plan: &CatalogDownloadPlan,
32 catalog_temp_path: &Path,
33 metadata_temp_path: &Path,
34 on_start: FStart,
35 on_progress: FProgress,
36) -> Result<()>
37where
38 FStart: FnOnce(Option<u64>),
39 FProgress: FnMut(u64),
40{
41 let CatalogDownloadPlan::Full {
42 catalog_url,
43 metadata_url,
44 expected_hash,
45 } = plan
46 else {
47 bail!("download_catalog_release only supports full snapshot plans");
48 };
49
50 let compressed_catalog_temp_path = compressed_snapshot_temp_path(catalog_temp_path);
51
52 let result = (|| -> Result<()> {
53 download_url_to_temp_file(
54 client,
55 metadata_url,
56 metadata_temp_path,
57 "catalog metadata asset",
58 |_| {},
59 |_| {},
60 |_| Ok(()),
61 )?;
62
63 let metadata = load_catalog_metadata(metadata_temp_path)?;
64
65 if let Some(expected_hash) = expected_hash
66 && metadata.current_hash.as_str() != expected_hash.as_str()
67 {
68 bail!(
69 "catalog metadata hash mismatch: expected {expected_hash}, got {}",
70 metadata.current_hash
71 );
72 }
73
74 download_url_to_temp_file(
75 client,
76 catalog_url,
77 &compressed_catalog_temp_path,
78 "catalog asset",
79 on_start,
80 on_progress,
81 |_| Ok(()),
82 )?;
83
84 decompress_catalog_snapshot(&compressed_catalog_temp_path, catalog_temp_path)?;
85 verify_catalog_hash(catalog_temp_path, &metadata.current_hash)?;
86
87 Ok(())
88 })();
89
90 let _ = std::fs::remove_file(&compressed_catalog_temp_path);
91
92 result
93}
94
95fn compressed_snapshot_temp_path(catalog_temp_path: &Path) -> PathBuf {
96 let file_name = catalog_temp_path
97 .file_name()
98 .and_then(|value| value.to_str())
99 .unwrap_or("catalog.db.download");
100
101 catalog_temp_path.with_file_name(format!("{file_name}.zst"))
102}
103
104fn decompress_catalog_snapshot(compressed_path: &Path, output_path: &Path) -> Result<()> {
114 let compressed_file =
115 File::open(compressed_path).context("failed to open compressed catalog snapshot")?;
116 let mut decoder = Decoder::new(compressed_file)
117 .context("failed to create zstd decoder for catalog snapshot")?;
118 let output_file =
119 File::create(output_path).context("failed to create catalog snapshot temp file")?;
120 let mut writer = BufWriter::new(output_file);
121
122 std::io::copy(&mut decoder, &mut writer).context("failed to decompress catalog snapshot")?;
123 writer.flush().context("failed to flush catalog snapshot")?;
124
125 let output_file = writer
126 .into_inner()
127 .map_err(|err| err.into_error())
128 .context("failed to finalize catalog snapshot temp file")?;
129 output_file
130 .sync_all()
131 .context("failed to sync catalog snapshot temp file")?;
132
133 Ok(())
134}
135
136#[cfg(test)]
137mod tests {
138 use super::super::types::CatalogDownloadPlan;
139 use super::{compressed_snapshot_temp_path, download_catalog_release};
140 use crate::core::network::build_client;
141 use crate::models::catalog::CatalogMetadata;
142 use std::collections::BTreeMap;
143 use tempfile::tempdir;
144 use winbrew_testing::MockServer;
145
146 #[test]
147 fn download_catalog_release_removes_internal_compressed_temp_file_on_decompression_failure() {
148 let temp_dir = tempdir().expect("temp dir");
149 let catalog_temp_path = temp_dir.path().join("catalog.db");
150 let metadata_temp_path = temp_dir.path().join("metadata.json");
151 let client = build_client("winbrew-app-tests").expect("build client");
152 let mut server = MockServer::new();
153
154 let metadata = CatalogMetadata::build_from_counts(
155 1,
156 BTreeMap::from([(String::from("winget"), 1)]),
157 String::from("sha256:expected"),
158 );
159 let metadata_url = format!("{}/metadata.json", server.url());
160 let catalog_url = format!("{}/catalog.db.zst", server.url());
161
162 let _metadata_mock = server.mock_get(
163 "/metadata.json",
164 serde_json::to_vec_pretty(&metadata).expect("serialize metadata"),
165 );
166 let _catalog_mock = server.mock_get("/catalog.db.zst", b"not valid zstd");
167
168 let plan = CatalogDownloadPlan::Full {
169 catalog_url,
170 metadata_url,
171 expected_hash: Some(String::from("sha256:expected")),
172 };
173
174 let result = download_catalog_release(
175 &client,
176 &plan,
177 &catalog_temp_path,
178 &metadata_temp_path,
179 |_| {},
180 |_| {},
181 );
182
183 let error = result.expect_err("expected decompression failure");
184
185 assert!(
186 error
187 .to_string()
188 .contains("failed to decompress catalog snapshot")
189 );
190 assert!(!compressed_snapshot_temp_path(&catalog_temp_path).exists());
191 assert!(metadata_temp_path.exists());
192 }
193}